chore(image-toolbox): release v2.3#301
Conversation
1. 新增功能:
- 图形工具新增平行四边形绘制
- 文字属性面板新增删除线复选框支持
2. 问题修复:
- 修复部分编辑操作首次撤销无反应的问题
- 修复打开新图片后撤销可能恢复上一张图片的问题
- 修复JPEG/WebP保存时实际写入PNG数据的问题
- 修复ZTools端选中文字后缺少描边位置设置的问题
- 修复裁剪撤销/重做时裁剪范围恢复不正确的问题
- 修复清除所有马赛克后无法撤销恢复的问题
3. 代码优化:
- 抽离公共工具函数到helpers模块,消除重复代码
- 重构渲染、边界计算、颜色处理等通用逻辑
- 完善组件销毁逻辑,正确解绑事件监听
- 优化历史栈去重和恢复逻辑
- 重构导出模块,支持宿主原生保存对话框
- 统一属性面板的转义工具函数使用
- 调整文件扩展名配置格式
Update version to 2.3 Add ColorModule for color adjustment features Add filters.js utility module Add ColorPanel UI component Refactor core modules and UI components
There was a problem hiding this comment.
Code Review
This pull request introduces a new 'Color' adjustment tool with filter presets and sliders, adds a parallelogram shape, and implements a strikethrough option for text. It also refactors common utility functions into a shared helper file and resolves several bugs related to undo/redo, event handling, and layer selection. The review feedback highlights critical issues, including missing history saves on filter changes (breaking undo), incorrect width calculations for the parallelogram shape, a missing getLayerByObject method in LayerManager, and potential memory leaks due to uncleaned DOM event listeners in ColorPanel. Additionally, performance optimizations are suggested for batch filter updates and snapshot comparisons in HistoryManager.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| if (eventType === 'change') { | ||
| eventBus.emit('canvas:objectModified', targets[0]); | ||
| } |
There was a problem hiding this comment.
在调整滤镜滑块时,当 eventType 为 'change'(即用户拖拽结束释放鼠标时),代码触发了 canvas:objectModified 事件,但并未调用 this.history?.saveState?.() 来保存历史状态。这会导致用户在调整亮度、对比度等滑块后,无法通过撤销(Undo)功能恢复到调整前的状态。\n\n建议在 eventType === 'change' 时,先调用 this.history?.saveState?.() 保存当前状态,再触发修改事件。
| if (eventType === 'change') { | |
| eventBus.emit('canvas:objectModified', targets[0]); | |
| } | |
| if (eventType === 'change') { | |
| this.history?.saveState?.(); | |
| eventBus.emit('canvas:objectModified', targets[0]); | |
| } |
| if (e.type === 'change') { | ||
| this._notifyObjectChanged(targets[0]); | ||
| } | ||
| } |
There was a problem hiding this comment.
| /** | ||
| * 设置/替换图片上的某类滤镜 | ||
| * @param {fabric.Image} image | ||
| * @param {string} type | ||
| * @param {number} uiValue | ||
| */ | ||
| export function setFilter(image, type, uiValue) { | ||
| if (!image) return; | ||
|
|
||
| if (!Array.isArray(image.filters)) { | ||
| image.filters = []; | ||
| } | ||
|
|
||
| const FilterClass = getFilterClass(type); | ||
| if (!FilterClass) return; | ||
|
|
||
| // 移除同类型旧滤镜 | ||
| const className = FILTER_CLASS_NAME[type]; | ||
| image.filters = image.filters.filter(f => !(f && f.type === className)); | ||
|
|
||
| // 默认值不写入(避免无效滤镜堆积) | ||
| const defaultValue = FILTER_RANGES[type] | ||
| ? FILTER_RANGES[type].default | ||
| : (PRESET_ONLY_DEFAULTS[type] != null ? PRESET_ONLY_DEFAULTS[type] : 0); | ||
| if (uiValue === defaultValue) { | ||
| applyFilters(image); | ||
| return; | ||
| } | ||
|
|
||
| const filterValue = uiToFilterValue(type, uiValue); | ||
| const attr = FILTER_ATTR_NAME[type]; | ||
| const filter = new FilterClass({ [attr]: filterValue }); | ||
| image.filters.push(filter); | ||
| applyFilters(image); | ||
| } |
There was a problem hiding this comment.
为 setFilter 增加 skipApply 参数,以便在批量设置滤镜(如应用预设)时,避免多次重复调用昂贵的 applyFilters(image) 操作,从而大幅提升性能。
export function setFilter(image, type, uiValue, skipApply = false) {
if (!image) return;
if (!Array.isArray(image.filters)) {
image.filters = [];
}
const FilterClass = getFilterClass(type);
if (!FilterClass) return;
// 移除同类型旧滤镜
const className = FILTER_CLASS_NAME[type];
image.filters = image.filters.filter(f => !(f && f.type === className));
// 默认值不写入(避免无效滤镜堆积)
const defaultValue = FILTER_RANGES[type]
? FILTER_RANGES[type].default
: (PRESET_ONLY_DEFAULTS[type] != null ? PRESET_ONLY_DEFAULTS[type] : 0);
if (uiValue === defaultValue) {
if (!skipApply) {
applyFilters(image);
}
return;
}
const filterValue = uiToFilterValue(type, uiValue);
const attr = FILTER_ATTR_NAME[type];
const filter = new FilterClass({ [attr]: filterValue });
image.filters.push(filter);
if (!skipApply) {
applyFilters(image);
}
}| export function applyFilterPreset(image, presetName) { | ||
| if (!image) return false; | ||
| const preset = FILTER_PRESETS.find(p => p.preset === presetName); | ||
| if (!preset) return false; | ||
|
|
||
| // 重置全部为默认值,再应用预设覆盖 | ||
| ALL_RESETTABLE_TYPES.forEach(type => { | ||
| const value = preset.filters[type] != null | ||
| ? preset.filters[type] | ||
| : (FILTER_RANGES[type] ? FILTER_RANGES[type].default : (PRESET_ONLY_DEFAULTS[type] != null ? PRESET_ONLY_DEFAULTS[type] : 0)); | ||
| setFilter(image, type, value); | ||
| }); | ||
| return true; | ||
| } |
There was a problem hiding this comment.
优化 applyFilterPreset,在循环设置滤镜时传入 skipApply = true,并在循环结束后统一调用一次 applyFilters(image),避免连续触发 8 次像素级的滤镜渲染,极大提升预设切换的流畅度。
| export function applyFilterPreset(image, presetName) { | |
| if (!image) return false; | |
| const preset = FILTER_PRESETS.find(p => p.preset === presetName); | |
| if (!preset) return false; | |
| // 重置全部为默认值,再应用预设覆盖 | |
| ALL_RESETTABLE_TYPES.forEach(type => { | |
| const value = preset.filters[type] != null | |
| ? preset.filters[type] | |
| : (FILTER_RANGES[type] ? FILTER_RANGES[type].default : (PRESET_ONLY_DEFAULTS[type] != null ? PRESET_ONLY_DEFAULTS[type] : 0)); | |
| setFilter(image, type, value); | |
| }); | |
| return true; | |
| } | |
| export function applyFilterPreset(image, presetName) { | |
| if (!image) return false; | |
| const preset = FILTER_PRESETS.find(p => p.preset === presetName); | |
| if (!preset) return false; | |
| // 重置全部为默认值,再应用预设覆盖 | |
| ALL_RESETTABLE_TYPES.forEach(type => { | |
| const value = preset.filters[type] != null | |
| ? preset.filters[type] | |
| : (FILTER_RANGES[type] ? FILTER_RANGES[type].default : (PRESET_ONLY_DEFAULTS[type] != null ? PRESET_ONLY_DEFAULTS[type] : 0)); | |
| setFilter(image, type, value, true); | |
| }); | |
| applyFilters(image); | |
| return true; | |
| } |
| _createParallelogram(left, top, width, height, props) { | ||
| const centerX = left + width / 2; | ||
| const centerY = top + height / 2; | ||
| const skewX = width * 0.22; | ||
| const points = [ | ||
| { x: -width / 2 + skewX, y: -height / 2 }, | ||
| { x: width / 2 + skewX, y: -height / 2 }, | ||
| { x: width / 2 - skewX, y: height / 2 }, | ||
| { x: -width / 2 - skewX, y: height / 2 }, | ||
| ]; | ||
|
|
||
| return new fabric.Polygon(points, { | ||
| ...props, | ||
| left: centerX, | ||
| top: centerY, | ||
| originX: 'center', | ||
| originY: 'center', | ||
| }); | ||
| } |
There was a problem hiding this comment.
在绘制平行四边形时,当前计算的顶点坐标中,左右两侧的倾斜量 skewX 是直接加在 width / 2 上的。这会导致生成的平行四边形实际总宽度为 width + 2 * skewX(即 1.44 * width),超出了用户拖拽框选的实际宽度 width。\n\n为了保证平行四边形能够完美契合用户的拖拽边界,应当缩减底边的基础宽度 baseWidth,使得 baseWidth + 2 * skewX = width。
_createParallelogram(left, top, width, height, props) {
const centerX = left + width / 2;
const centerY = top + height / 2;
// 为了使平行四边形的总宽度(包含倾斜部分)正好等于拖拽宽度 width,
// 我们需要缩减底边的宽度。设倾斜量为总宽度的 15%,则底边宽度为总宽度的 70%。
const skewX = width * 0.15;
const baseWidth = width * 0.7;
const points = [
{ x: -baseWidth / 2 + skewX, y: -height / 2 },
{ x: baseWidth / 2 + skewX, y: -height / 2 },
{ x: baseWidth / 2 - skewX, y: height / 2 },
{ x: -baseWidth / 2 - skewX, y: height / 2 },
];
return new fabric.Polygon(points, {
...props,
left: centerX,
top: centerY,
originX: 'center',
originY: 'center',
});
}| _refreshMetaState(meta, obj, isBackground = false) { | ||
| meta.visible = obj.visible !== false; | ||
| meta.locked = this._resolveLockedState(obj, isBackground); | ||
| meta.isBackground = !!isBackground; | ||
| } |
There was a problem hiding this comment.
在 BaseModule.js 中,代码调用了 layerManager?.getLayerByObject?.(obj)。然而,LayerManager 类中并没有定义 getLayerByObject 方法,这会导致该调用始终返回 undefined 并降级使用 obj._layerLocked。\n\n建议在 LayerManager 中显式实现 getLayerByObject 方法,以保证接口的完整性和设计的统一性。
_refreshMetaState(meta, obj, isBackground = false) {
meta.visible = obj.visible !== false;
meta.locked = this._resolveLockedState(obj, isBackground);
meta.isBackground = !!isBackground;
}
getLayerByObject(obj) {
return this._layers.find(l => l.fabricObj === obj) || null;
}| destroy() { | ||
| this._eventBusUnsubscribers.forEach(unsub => unsub()); | ||
| this._eventBusUnsubscribers = []; | ||
| } |
There was a problem hiding this comment.
在 ColorPanel 初始化时,代码直接向 this._el 绑定了 input、change 和 click 事件监听器。然而在 destroy() 方法中,只清理了 eventBus 的订阅,并没有移除这些 DOM 事件监听器。\n\n如果 ColorPanel 被销毁并重新创建,或者宿主容器持续存在,这些未清理的监听器会导致内存泄漏,并可能在后续操作中触发重复的事件回调。\n\n建议在 _bindEvents 中保存绑定的事件处理函数,并在 destroy() 中进行清理。
destroy() {
this._eventBusUnsubscribers.forEach(unsub => unsub());
this._eventBusUnsubscribers = [];
if (this._boundHandleEvent) {
this._el.removeEventListener('input', this._boundHandleEvent);
this._el.removeEventListener('change', this._boundHandleEvent);
this._el.removeEventListener('click', this._boundHandleEvent);
}
}| _isSameSnapshot(a, b) { | ||
| if (a === b) return true; | ||
| if (!a || !b) return false; | ||
|
|
||
| try { | ||
| return JSON.stringify(a) === JSON.stringify(b); | ||
| } catch (err) { | ||
| return false; | ||
| } | ||
| } |
There was a problem hiding this comment.
更新内容